aws-jwt-verifyでCognitoのアクセストークン検証をHonoのMiddlewareとして実装してみた
はじめに
Honoが好きなコンサル部の神野です。今日はaws-jwt-verify
について紹介させてください。
aws-jwt-verify
皆さん、aws-jwt-verify
をご存知ですか? 私はふと、Cognitoのトークン検証について下記公式ドキュメントを調べていたところ、トークン検証に特化したライブラリがあるんだと知って喜びました!
公式ドキュメントからの引用
In a Node.js app, AWS recommends the aws-jwt-verify library to validate the parameters in the token that your user passes to your app... 引用部分は省略
Node.jsアプリケーションならaws-jwt-verify
を使うことを推奨すると書いてありますね!ますます気になります。
ライブラリの特徴
aws-jwt-verify
は、CognitoやOIDC互換のIdPが発行するトークンの検証をよしなにやってくれるライブラリです。
主な特徴
- トークンの署名検証や有効期限チェックを実行
- JWKSエンドポイントからの公開鍵取得をキャッシュで効率化
- JWKSエンドポイントへのアクセスレート制限機能により過度なアクセスを防止
上記機能により、自前で検証機構を実装するよりも簡単に作れそうです。
引用したAWSの公式ドキュメントにも記載があるよう、Node.jsアプリケーションではこのライブラリの使用を推奨しているのも魅力的ですね!
今回の検証について
レポジトリを見ると、express
やfastify
などのWebフレームワークの実装例が紹介されていますが、せっかくなのでHono
好きな私としては、Hono
のMiddlewareとしてaws-jwt-verify
をCognitoのアクセストークン検証機構に組み込んで使ってみたいと思います!
前提
本記事で使用する環境およびライブラリのバージョンは下記のとおりです。
実行環境
- Node.js: v20.16.0
使用パッケージ
- @hono/node-server: 1.13.7
- aws-jwt-verify: 4.0.1
- hono: 4.6.15
準備
Hono環境構築
任意のフォルダで下記コマンドを実行しHonoの環境を構築します。今回はnodejs
および依存関係のパッケージマネージャーはnpm
を使用します。
npm create hono@latest my-app
create-hono version 0.14.3
✔ Using target directory … my-app
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd my-app
# 作成が完了したらプロジェクトへ階層を移動
cd my-app
今回使用するライブラリaws-jwt-verify
もインストールしておきます。
npm install aws-jwt-verify
これで一旦Honoの実行環境は作成完了で、次にCognitoの環境を作成します。
Cognito環境構築
検証用のCognito環境を構築します。下記シェルスクリプトを実行することで、以下の一連の処理を自動で行います。
- ユーザープールの作成
- アプリケーションクライアントの作成
- テストユーザーの登録と確認
- アクセストークンの取得
実行前に、以下の変数を環境に合わせて設定してください。
設定する変数の例
USER_POOL_NAME="任意のユーザープール名"
APP_CLIENT_NAME="任意のクライアントアプリ名"
EMAIL="登録するメールアドレス"
PASSWORD="パスワード(8文字以上、大小文字・数字・記号を含む)"
実行するシェル
create-cognito.sh
として下記シェルを作成します。
#!/bin/bash
# 変数設定
USER_POOL_NAME="CognitoUserPool"
APP_CLIENT_NAME="CognitoAppClient"
EMAIL="your-email"
PASSWORD="your-password"
# User Pool の作成
echo "Creating User Pool..."
USER_POOL_ID=$(aws cognito-idp create-user-pool \
--pool-name "$USER_POOL_NAME" \
--policies '{"PasswordPolicy":{"MinimumLength":8,"RequireUppercase":true,"RequireLowercase":true,"RequireNumbers":true,"RequireSymbols":true}}' \
--username-attributes email \
--query 'UserPool.Id' \
--output text)
# User Pool Client の作成
# callback-urlsなどは検証のため適当な値を設定しています。
echo "Creating User Pool Client..."
CLIENT_ID=$(aws cognito-idp create-user-pool-client \
--user-pool-id $USER_POOL_ID \
--client-name "$APP_CLIENT_NAME" \
--no-generate-secret \
--explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--supported-identity-providers COGNITO \
--callback-urls '["http://localhost:3000/callback"]' \
--logout-urls '["http://localhost:3000/logout"]' \
--allowed-o-auth-flows code \
--allowed-o-auth-scopes "email" "openid" "profile" \
--allowed-o-auth-flows-user-pool-client \
--query 'UserPoolClient.ClientId' \
--output text)
# ユーザーの登録
echo "Signing up user..."
aws cognito-idp sign-up \
--client-id $CLIENT_ID \
--username $EMAIL \
--password $PASSWORD
# ユーザーの確認(admin confirm)
echo "Confirming user..."
aws cognito-idp admin-confirm-sign-up \
--user-pool-id $USER_POOL_ID \
--username $EMAIL
# 認証とトークンの取得
echo "Authenticating user..."
AUTH_RESULT=$(aws cognito-idp initiate-auth \
--client-id $CLIENT_ID \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=$EMAIL,PASSWORD=$PASSWORD)
# 結果の表示
echo "Summary:"
echo "User Pool ID: $USER_POOL_ID"
echo "Client ID: $CLIENT_ID"
echo "Access Token: $(echo "$AUTH_RESULT" | jq -r '.AuthenticationResult.AccessToken')"
echo "ID Token: $(echo "$AUTH_RESULT" | jq -r '.AuthenticationResult.IdToken')"
実行結果と必要な情報の取得
まず、スクリプトをCloudShellで実行できるように準備します。以下のいずれかの方法で実行用のシェルスクリプトを作成してください。
- 方法1: nanoエディタで直接作成
nano create-cognito.sh
- 方法2: ローカルでスクリプトファイルを作成し、CloudShellへ転送
作成が完了したら、実行します。
sh ./create-cognito.sh
実行結果のイメージ
Creating User Pool...
Creating User Pool Client...
Signing up user...
Confirming user...
Authenticating user...
Summary:
User Pool ID: ap-northeast-1_zzzz
Client ID: yyyyyy
Access Token: xxx
ID Token: yyy
この実行結果から、以下の3つの値を控えておいてください。後ほどアプリケーションの実装時に使用します。
- User Pool ID
- Client ID
- Access Token
なお、アクセストークンは1時間で有効期限が切れます。期限切れの場合は、以下のコマンドで新しいトークンを取得します。
aws cognito-idp initiate-auth \
--client-id <作成したクライアントアプリケーションのID> \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=<登録したメールアドレス>,PASSWORD=<登録したパスワード>
補足
シェルで作成したユーザープールおよびクライアントアプリケーションのIDが不明になった場合は画面上から確認できます。
ユーザープールID
アプリケーションクライアントID
実装
src/index.ts
にaws-jwt-verify
を使用したMiddlewareの実装と、トークンが有効な場合はtoken valid
を返却する簡単なエンドポイントを実装します。
コード全体
import { Hono } from "hono";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { serve } from "@hono/node-server";
import { logger } from "hono/logger";
import { createMiddleware } from "hono/factory";
const app = new Hono();
// JWT Verifierの作成
const verifier = CognitoJwtVerifier.create({
userPoolId: "<準備セクションで取得したUser Pool ID>",
tokenUse: "access",
clientId: "<準備セクションで取得したClient ID>",
});
// JWKSキャッシュの事前読み込み(オプション)
await verifier.hydrate();
// JWT検証のMiddleware
const authMiddleware = createMiddleware(async (c, next) => {
try {
const token = c.req.header("authorization");
if (!token) {
return c.json(
{
message: "Authorization header missing",
},
401
);
}
// JWTの検証
const payload = await verifier.verify(token);
console.log(payload);
await next();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
return c.json(
{
message: "Token not valid",
detail: errorMessage,
},
403
);
}
});
// Middlewareを適用
app.use(logger());
app.use("/*", authMiddleware);
app.get("/", (c) => {
return c.json({ message: "token valid!" });
});
serve(app);
要点
aws-jwt-verifyの実装
認証用のクライアントオブジェクトを作って、verify
メソッドを検証します。
verifier
クラスのインスタンス作成時には、準備セクションで取得した以下の値を設定します。
userPoolId
:CognitoユーザープールのプールIDclientId
:アプリケーションクライアントのIDtokenUse
:今回はaccess
(アクセストークン)を使用(IDトークンも検証可能で、その場合はid
を指定する)
const verifier = CognitoJwtVerifier.create({
userPoolId: "<準備セクションで取得したUser Pool ID>",
tokenUse: "access",
clientId: "<準備セクションで取得したClient ID>",
});
// JWKSキャッシュの事前読み込み(オプション)
await verifier.hydrate();
// tokenを検証
await verifier.verify(token)
CognitoJwtVerifier.create()
のオプションとしては下記となります。
userPoolId
(必須): Cognitoユーザープールのプール IDtokenUse
(必須): トークンの用途を指定。id
(IDトークン)またはaccess
(アクセストークン)。clientId
(必須): アプリケーションクライアントID。トークンのaud
(IDトークン)またはclient_id
(アクセストークン)と照合groups
(任意): トークンに含まれるcognito:groups
の検証scope
(任意): アクセストークンのスコープ検証graceSeconds
(任意): トークンの有効期限に対する猶予時間(秒)
今回の実装では基本的な設定であるuserPoolId
、tokenUse
、clientId
のみを使用していますが、より厳密な検証が必要な場合は、groups
やscope
などの追加オプションを活用することができます。
JWKSキャッシュについて
長時間稼働するNode.jsアプリケーション(例:Fargateコンテナ)では、サーバー起動時にJWKSキャッシュを事前に読み込むことで、初回のトークン検証を高速化も可能です。
// キャッシュの事前読み込み
await verifier.hydrate();
hydrate()
メソッドは、設定された全てのissuerの最新のJWKSを取得しキャッシュします- コンテナ起動時など、トラフィックが流れていない待機時間中に実行するのが効果的です
- Lambda@EdgeやAPI Gateway Lambda Authorizerでは、既存のキャッシュをバイパスしてしまうため、逆にパフォーマンスが低下する可能性があります
なお、レポジトリのReadmeによると、JWKSキャッシュは以下のように動作します。
JWKSキャッシュは最初に1回フェッチされ、その後はキーローテーションの際にのみフェッチされます(キャッシュにまだ存在しないkidを持つJWTが検出された場合)
hydrate()
は初期化用途であり、その後のキーローテーションは自動的に処理されます。定期的なキャッシュクリアが必要な場合は、別途スケジュールを設定することも可能です。
// 4時間ごとにキャッシュをクリア
setInterval(
() => {
verifier.cacheJwks({ keys: [] }); // 空のJWKSをロードしてキャッシュをクリア
},
1000 * 60 * 60 * 4 // 4時間間隔
);
自前でキャッシュやキーローテーション機能を作るのは少し手間なので、ライブラリ側でコントロールしてくれるのはめちゃくちゃ嬉しいですね!!
Middlewareについて
HonoのMiddlewareを使って、トークン検証の処理を実装します。Honoの公式ドキュメントを参考に、以下のような実装を行いました!
import { createMiddleware } from "hono/factory";
// JWT検証のMiddleware
const authMiddleware = createMiddleware(async (c, next) => {
try {
const token = c.req.header("authorization");
if (!token) {
return c.json(
{
message: "Authorization header missing",
},
401
);
}
// JWTの検証
const payload = await verifier.verify(token);
console.log(payload);
await next();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
return c.json(
{
message: "Token not valid",
detail: errorMessage,
},
403
);
}
});
// Middlewareを全エンドポイントに適応
app.use("/*", authMiddleware);
作成したMiddlewareはapp.use("/*",authMiddleware)
で全エンドポイントに適応し、以下の処理を行っています。
Authorization
ヘッダーの値を取得し、トークンが存在しない場合は401エラーを返却aws-jwt-verify
を使用してトークンを検証、検証に成功した場合はリクエスト先のエンドポイントの処理へ- 検証時にエラーが発生した場合は403エラーを返却
めちゃくちゃシンプルに実装できていいですね・・・!!このようにシンプルに実装できるのはHonoの魅力ですね!
動作確認
まずは下記コマンドでサーバーを起動します。
npm run dev
http://localhost:3000
でサーバーが立ち上がったら、まずは準備セクションで取得したアクセストークンをAuthorization
ヘッダーに設定して、リクエストを送信してみます!
実行コマンドと結果(正常系)
# 実行コマンド
curl http://localhost:3000 \
-H "Authorization: <準備セクションで取得したAccess Token>" -i
# 実行結果
HTTP/1.1 200 OK
content-type: application/json
content-length: 26
Date: Sat, 28 Dec 2024 16:25:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"token valid!"}
無事検証成功して、ステータスコード200でtoken valid!
が返却されましたね!
補足:payloadについて
また、console.log(payload)
で以下のようなペイロード情報が確認できます。
{
"sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/<User Pool ID>",
"client_id": "<Client ID>",
"token_use": "access",
"scope": "aws.cognito.signin.user.admin",
"auth_time": 1735402386,
"exp": 1735405986,
"iat": 1735402386,
"username": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
各項目の説明は以下のとおりです。使用される際は必要に応じてご参照ください。
sub
: ユーザーの一意識別子iss
: トークン発行者(Cognito)のURLclient_id
: アプリケーションクライアントのIDtoken_use
: トークンタイプ(access/id)scope
: トークンのアクセス範囲auth_time
: 認証が行われた時刻exp
: トークンの有効期限iat
: トークンが発行された時刻username
: Cognitoで管理されているユーザーのID
正常系は確認できたので、今度は異常系について確認していきたいと思います。
実行コマンドと結果(異常系)
正常系は無事に確認できたので異常系もいくつか試していきたいと思います。
Authorizationヘッダーを空でリクエスト
Authorization
ヘッダーを空でリクエストしてみます。
# 実行コマンド
curl http://localhost:3000 -i
# 実行結果
HTTP/1.1 401 Unauthorized
content-type: application/json
content-length: 42
Date: Sat, 28 Dec 2024 16:24:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Authorization header missing"}
ステータスコード401でAuthorization header missing
と返却されましたね。Middlewareの実装通りで期待値を満たしています。
不正なトークンを送信
トークンをaaa
でリクエストしてみます。
# 実行コマンド
curl http://localhost:3000 \
-H "Authorization: aaa" -i
# 実行結果
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 41
Date: Sat, 28 Dec 2024 16:28:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Token not valid","detail":"JWT string does not consist of exactly 3 parts (header, payload, signature)"}
ステータスコードは403でToken not valid
と返却されましたね。詳細なエラーメッセージとしてはJWTとして文字列が成立していないと返却されていますね。
署名部を変更したトークンを送信
事前に取得したトークンの署名部の最後の箇所にa
を追記したトークンを送信します。
# 実行コマンド
curl http://localhost:3000 \
-H "Authorization: <準備セクションで取得したAccess Token>a" -i
# 実行結果
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 41
Date: Sat, 28 Dec 2024 16:28:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Token not valid","detail":"Invalid signature"}
ステータスコードは403でToken not valid
と返却されましたね。詳細なエラーメッセージとしては有効な署名ではないと返却されました。
有効期限切れのトークンを送信
有効期限が切れたアクセストークンでリクエストしてみます。
# 実行コマンド
curl http://localhost:3000 \
-H "Authorization: <有効期限が切れたAccess Token>" -i
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 80
Date: Sat, 28 Dec 2024 16:32:03 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Token not valid","detail":"Token expired at 2024-12-28T13:55:37.000Z"}
こちらもステータスコードは403でToken not valid
と返却されましたね。エラーメッセージはトークン有効期限切れを示しているメッセージが返却されました。
異常系もバッチリ検出してくれていますね!!
おわりに
aws-jwt-verify
はいかがだったでしょうか。ライブラリを活用することでトークンの検証もかなり楽にできますし、JWKSエンドポイントからの公開鍵取得のキャッシュやローテーション機能など自前で実装するのが少し手間な部分も作られていて嬉しいですね。
今回はHono
のMiddlewareとして検証機構を組み込みましたが、Hono
以外でもCloudFront Lambda@Edgeや、API ゲートウェイ Lambda オーソライザー などバックエンドサーバーの手前でaws-jwt-veriffy
を組み込んでトークン検証するのも手かと思いますし、Honoを使ったバックエンドサーバーで検証したい!となった際は少しでも参考になったら幸いです。
最後までご覧いただきありがとうございました!!
補足
下記アーキテクチャの場合は公式のレポジトリにも使い方や説明があるので、マッチするケースがある場合はご参照ください。